package com.ocean.aws.relay;

import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;

import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.NbtIo;
import net.minecraft.nbt.NbtAccounter;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;

/**
 * Relay skin nodes via Redis (transport/cache only). Local filesystem remains the source of truth.
 */
public final class SkinRelay {

    private final String keyPrefix;
    private final RedisClient client;
    private final StatefulRedisConnection<byte[], byte[]> connection;
    private final RedisCommands<byte[], byte[]> commands;
    private static final String WARDROBE_LAST_PREFIX = "aw:wardrobe:last:";

    public SkinRelay(String redisUri, String username, String password, String keyPrefix) {
        this.keyPrefix = keyPrefix.endsWith(":") ? keyPrefix : (keyPrefix + ":");
        RedisURI uri = RedisURI.create(redisUri);
        if (username != null && !username.isEmpty()) {
            uri.setUsername(username);
        }
        if (password != null && !password.isEmpty()) {
            uri.setPassword(password.toCharArray());
        }
        this.client = RedisClient.create(uri);
        this.connection = client.connect(new io.lettuce.core.codec.ByteArrayCodec());
        this.commands = connection.sync();
    }

    public void shutdown() {
        try { connection.close(); } catch (Throwable ignored) {}
        try { client.shutdown(); } catch (Throwable ignored) {}
    }

    public int collectAndPushFromWardrobeNbt(byte[] wardrobeCompressedNbt, Path objectsDir) throws Exception {
        int pushed = 0;
        for (String nodeId : extractNodeIds(wardrobeCompressedNbt)) {
            Path nodePath = objectsDir.resolve(nodeId);
            if (!Files.exists(nodePath)) continue; // nothing to push
            String key = keyPrefix + nodeId;
            if (commands.get(k(key)) != null) continue; // already cached
            byte[] zip = zipDirectory(nodePath);
            if (zip != null && zip.length > 0) {
                commands.set(k(key), zip);
                pushed++;
            }
        }
        return pushed;
    }

    public int ensureNodesFromWardrobeNbt(byte[] wardrobeCompressedNbt, Path objectsDir) throws Exception {
        int restored = 0;
        for (String nodeId : extractNodeIds(wardrobeCompressedNbt)) {
            Path nodePath = objectsDir.resolve(nodeId);
            if (Files.exists(nodePath)) continue; // already present locally
            String key = keyPrefix + nodeId;
            byte[] zip = commands.get(k(key));
            if (zip != null && zip.length > 0) {
                unzipToDirectory(zip, nodePath);
                restored++;
            }
        }
        return restored;
    }

    /**
     * Push all local node directories under objectsDir to Redis if not already cached.
     */
    public int pushAllLocalNodes(Path objectsDir) {
        int pushed = 0;
        try {
            if (!Files.isDirectory(objectsDir)) return 0;
            try (var stream = Files.list(objectsDir)) {
                for (Path nodeDir : (Iterable<Path>) stream::iterator) {
                    if (!Files.isDirectory(nodeDir)) continue;
                    String nodeId = nodeDir.getFileName().toString();
                    if (!ID_PATTERN.matcher(nodeId).matches()) continue;
                    String key = keyPrefix + nodeId;
                    if (commands.get(k(key)) != null) continue; // already exists in cache
                    try {
                        byte[] zip = zipDirectory(nodeDir);
                        if (zip != null && zip.length > 0) {
                            commands.set(k(key), zip);
                            pushed++;
                        }
                    } catch (Throwable ignored) {}
                }
            }
        } catch (Throwable ignored) {}
        return pushed;
    }

    /**
     * Pull all cached node directories from Redis to local objectsDir if missing.
     */
    public int pullAllToLocal(Path objectsDir) {
        int restored = 0;
        try {
            Files.createDirectories(objectsDir);
            byte[] pattern = k(keyPrefix + "*");
            List<byte[]> keys;
            try {
                keys = commands.keys(pattern);
            } catch (Throwable t) {
                keys = new ArrayList<>();
            }
            for (byte[] kb : keys) {
                String key = new String(kb, StandardCharsets.UTF_8);
                if (!key.startsWith(keyPrefix)) continue;
                String nodeId = key.substring(keyPrefix.length());
                if (!ID_PATTERN.matcher(nodeId).matches()) continue;
                Path nodeDir = objectsDir.resolve(nodeId);
                if (Files.exists(nodeDir)) continue;
                try {
                    byte[] zip = commands.get(k(key));
                    if (zip != null && zip.length > 0) {
                        unzipToDirectory(zip, nodeDir);
                        restored++;
                    }
                } catch (Throwable ignored) {}
            }
        } catch (Throwable ignored) {}
        return restored;
    }

    /**
     * Two-way sync with Redis cache: push local -> Redis, pull Redis -> local.
     */
    public int syncObjectsDirectory(Path objectsDir) {
        int pushed = pushAllLocalNodes(objectsDir);
        int pulled = pullAllToLocal(objectsDir);
        return pushed + pulled;
    }

    private static byte[] k(String s) {
        return s.getBytes(StandardCharsets.UTF_8);
    }

    public static Set<String> extractNodeIds(byte[] wardrobeCompressedNbt) throws IOException {
        // Read compressed NBT and scan for string-like tokens that look like node ids
        CompoundTag tag = NbtIo.readCompressed(new ByteArrayInputStream(wardrobeCompressedNbt), NbtAccounter.unlimitedHeap());
        Set<String> ids = new HashSet<>();
        scanTag(tag, ids);
        return ids;
    }

    private static void scanTag(Object nbt, Set<String> sink) {
        if (nbt instanceof CompoundTag ct) {
            for (String k : ct.getAllKeys()) {
                var t = ct.get(k);
                if (t instanceof net.minecraft.nbt.StringTag st) {
                    collectPossibleId(st.getAsString(), sink);
                } else if (t instanceof CompoundTag) {
                    scanTag(t, sink);
                } else if (t instanceof net.minecraft.nbt.ListTag lt) {
                    for (int i = 0; i < lt.size(); i++) {
                        var e = lt.get(i);
                        if (e instanceof net.minecraft.nbt.StringTag st2) collectPossibleId(st2.getAsString(), sink);
                        else if (e instanceof CompoundTag) scanTag(e, sink);
                    }
                }
            }
        }
    }

    private static final Pattern ID_PATTERN = Pattern.compile("[A-Za-z0-9]{6,32}");

    private static void collectPossibleId(String s, Set<String> sink) {
        Matcher m = ID_PATTERN.matcher(s);
        while (m.find()) {
            sink.add(m.group());
        }
    }

    private static byte[] zipDirectory(Path dir) throws IOException {
        if (!Files.isDirectory(dir)) return null;
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try (ZipOutputStream zos = new ZipOutputStream(baos)) {
            Files.walk(dir).forEach(p -> {
                if (Files.isDirectory(p)) return;
                try {
                    String entryName = dir.relativize(p).toString().replace('\\', '/');
                    zos.putNextEntry(new ZipEntry(entryName));
                    Files.copy(p, zos);
                    zos.closeEntry();
                } catch (IOException ignored) {}
            });
        }
        return baos.toByteArray();
    }

    private static void unzipToDirectory(byte[] zip, Path targetDir) throws IOException {
        Files.createDirectories(targetDir);
        try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) {
            ZipEntry e;
            while ((e = zis.getNextEntry()) != null) {
                Path out = targetDir.resolve(e.getName()).normalize();
                Files.createDirectories(out.getParent());
                Files.copy(zis, out, StandardCopyOption.REPLACE_EXISTING);
                zis.closeEntry();
            }
        }
    }

    // Fallback storage for latest wardrobe bytes by player UUID
    public void setLatestWardrobe(java.util.UUID uuid, byte[] compressedNbt) {
        try { commands.set(k(WARDROBE_LAST_PREFIX + uuid), compressedNbt); } catch (Throwable ignored) {}
    }

    public byte[] getLatestWardrobe(java.util.UUID uuid) {
        try { return commands.get(k(WARDROBE_LAST_PREFIX + uuid)); } catch (Throwable ignored) { return null; }
    }
}


